Skip to content

feat(zones)!: deliver Observable read values as microtasks - #3833

Merged
armando-navarro merged 2 commits into
angular:mainfrom
armando-navarro:a41-microtask-delivery
Nov 4, 2026
Merged

armando-navarro merged 2 commits into
angular:mainfrom
armando-navarro:a41-microtask-delivery

Conversation

@armando-navarro

Copy link
Copy Markdown
Collaborator

Checklist

Description

Builds the insideAngular scheduler in src/zones.ts over a private AsapScheduler instance instead of rxjs's asyncScheduler, so every value from an Observable-returning read reaches the subscriber at the next microtask checkpoint instead of after a setInterval turn.

  • The instance is AngularFire's own rather than rxjs's shared asapScheduler because the shared one runs every action scheduled in a turn as one batch and cancels the rest of the batch when one action throws, which would let another library's action drop a delivery.
  • AsapScheduler and AsapAction come from rxjs/internal/scheduler/, a path rxjs 7.8 lists in its exports map but keeps under internal.
  • Nothing else in the wrapper changes: Promise-returning and callback-taking wrappers never used this scheduler.

What this grants, measured on Angular 22.2 zone.js and zoneless apps, before and after:

  • Values arrive before a zero-delay timer registered when the source emitted, in every run: a synthetic source in Chrome and Firefox, and collectionData against the Firestore emulator and the compat authState against the Auth emulator in Chrome.
  • A server render reports stable about 1 ms sooner after Firestore's last callback.

What does not change:

  • The DOM updates on delivery and the subscriber runs inside the Angular zone.
  • zone.js ticks once per value and zoneless once per burst.
  • Order is preserved.

What upgrading apps may notice:

  • A test that waits only for microtasks (a few awaited promises, vitest fake timers without advancing, fakeAsync with flushMicrotasks() alone) now sees a value whose source emits in a microtask, as Auth and a promise-based mock do. Firestore hands its snapshots over in a setTimeout, so Firestore reads still need a timer to advance. Tests that advance timers are unchanged.
  • Code that registers its own setTimeout when a read emits sees that timer run after the value arrives, not before.
  • In a zoneless server render, a value arriving between the app reporting stable and serialization now reaches a hand-written TransferState entry but not the HTML, because the zoneless change-detection pass is itself a later timer. Before this change such a value reached neither.
  • The compat AngularFireAuth streams move with the modular reads. Compat AngularFireFunctions and AngularFireMessaging only hand over their SDK instance through this scheduler, and compat Database and Firestore do not use it.

Verification:

  • New spec src/zones.spec.ts: a wrapped Observable's value arrives asynchronously, inside the Angular zone, and before a zero-delay timer registered when the source emitted, and the delivery scheduler is an AsapScheduler that is not rxjs's shared instance. Fails on main, passes here.
  • npm run lint, npm run test:node, npm run test:chrome-headless and npm run test:firefox-headless pass on the branch.

The insideAngular scheduler was built over asyncScheduler, so every
value from an Observable read reached its subscriber one setInterval
turn after Firebase handed it over. A private AsapScheduler instance
delivers at the next microtask checkpoint instead, still never
synchronously. It is private because rxjs's shared asapScheduler
cancels the rest of a batch when one action throws.

Measured on Angular 22.2 apps, zone.js and zoneless: delivery moves
ahead of a zero-delay timer in every run, change detection and
ordering are unchanged, and a server render reports stable about a
millisecond sooner. The compat AngularFireAuth streams move too.

BREAKING CHANGE: Observable reads (collectionData, docData, authState,
user, objectVal, listVal and the rest) now deliver at the next
microtask checkpoint instead of after a zero-delay timer. A test that
waits only for microtasks now sees a value whose source emits in a
microtask. Code that orders its own setTimeout against a read's value
sees the timer run after the value, not before. In a zoneless server
render, a value arriving between stability and serialization reaches
a hand-written TransferState entry but not the HTML.

Fixes angular#3832
@armando-navarro armando-navarro added breaking change Upgrading apps must change something. Call it out in the release notes bump: major Size of the change: breaking, ships only in a major release comp: compat Compatibility layer for the pre-modular API (src/compat). comp: ssr Server-side rendering, hydration, @angular/ssr interop. comp: zones Change detection / zone.js / zoneless. type: feature New capability or enhancement. labels Oct 30, 2026

@tyler-reitz tyler-reitz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. I confirmed the new spec fails on both counts when asyncScheduler is swapped back in, and passes on the branch. One ask before rc.1: the body says the upgrade-guide entry lands in a separate docs PR, but there isn't one yet and #3835 doesn't mention it. Could you add the entry to #3835 so the breaking change ships with its migration note?

armando-navarro added a commit to armando-navarro/angularfire that referenced this pull request Nov 4, 2026
angular#3833 moves every Observable read from a setInterval timer to the next
microtask checkpoint. As a breaking change it needs a section in the
upgrade guide: which tests now see a value they used to wait for, the
setTimeout ordering flip, and the zoneless server render case for
hand-written TransferState entries.

Refs angular#3832
@armando-navarro

Copy link
Copy Markdown
Collaborator Author

Thanks, Tyler. I've added the docs update in a commit on #3835, a new section "Observable reads deliver as microtasks" in docs/version-22-upgrade.md with the test-timing, setTimeout ordering and zoneless server render notes from this PR's description.

@armando-navarro
armando-navarro enabled auto-merge (squash) November 4, 2026 17:40
@armando-navarro
armando-navarro merged commit 7206aef into angular:main Nov 4, 2026
25 checks passed
@armando-navarro armando-navarro added this to the 22.0.0-rc.1 milestone Nov 4, 2026
armando-navarro added a commit that referenced this pull request Nov 13, 2026
* docs: say which wrapped calls do not hold the app unstable

docs/zones.md says callback APIs hold the app unstable until an initial
value, so a server render waits for data. #3827 makes a set of calls
exceptions because their first callback may never come. Waiting on a
callback that never comes would keep the app from ever becoming stable.

List those calls in docs/zones.md, and point at onValue, object, list,
listVal and fromRef with the value event as the calls that still make a
server render wait.

In docs/database.md, say the same under stateChanges, auditTrail and
fromRef.

Refs #3826

* docs: describe firebase 13 support in the v22 upgrade guide

#3831 lets AngularFire 22 accept firebase 12 or 13, and the ng update
migration now writes the range of the firebase major already installed.
The upgrade guide still said the migration writes ^12.18.0 and quoted
peer warnings that name only that range.

State the supported range and what the migration writes, update the
Yarn 1 and pnpm 9/10 warnings to their current wording, and add a
section on moving to firebase 13: VertexAIBackend is removed and its
packages declare Node 24.12 or later.

Refs #3830

* docs: describe microtask delivery in the v22 upgrade guide

#3833 moves every Observable read from a setInterval timer to the next
microtask checkpoint. As a breaking change it needs a section in the
upgrade guide: which tests now see a value they used to wait for, the
setTimeout ordering flip, and the zoneless server render case for
hand-written TransferState entries.

Refs #3832

* docs: describe the callback pending-task change in the v22 upgrade guide

#3837 removes a callback read's pending task right after the callback
instead of one setTimeout(fn, 0) delay later. As a breaking change it
needs a section in the upgrade guide: what moves sooner, and which
tests notice.

Refs #3836

* docs: say a handler passed as an observer object is wrapped too

#3829 wraps a handler passed as an observer object, such as { next, error }, the way it wraps a function, so the injection context reaches both forms.

Refs #3828

* docs(compat): add a setup section in NgModule and standalone form

The compat API's setup steps left docs/install-and-setup.md when #3236
rewrote it for the modular API, and nothing replaced them. docs/compat.md
had no setup at all, the Realtime Database pages pointed at an
"Installation guide" that no longer covered compat, and no page showed
the standalone form. A standalone app with only the modular providers
then fails its first compat injection with NG0201 for
angularfire2.app.options (#3683).

The new section on docs/compat.md gives both forms, says when the modular
providers may stay, states the error the missing module produces, and
lists the compat module of each product. The twelve compat pages that had
an NgModule-only snippet or no setup at all now point at it.

Fixes #3683

* docs: say which package managers refuse an incompatible firebase

The v22 upgrade guide said an incompatible firebase is refused at
install time. Only npm refuses it, and only when package.json requests
the incompatible version. Yarn and pnpm install it with a warning.

* docs: say which Firestore functions are not zone wrapped

#3842 re-exports Firestore's query and reference builders, field
values, comparators and setLogLevel without the zone wrapper. In
docs/zones.md, add a paragraph listing them, change the switchMap
example to a read, since building the query no longer warns, and say
the Verbose log level covers only wrapped calls.

Refs #3842

* docs(auth): build the server app with initializeBrowserOrServerApp

The server-rendering guide's step 4 becomes the one call, the five
details become three, the App Check token gets a relay section, the
response gets a Cache-Control header, and the manual callback stays in
a "Building the app yourself" section that step 4 points at, for apps
that prefer it or whose AngularFire version has no such function. The
release modes say what a throwing ngOnDestroy does to a timed release.

* docs(auth): describe the sample as an example of the one-call server app

The sample app now has one provideFirebaseApp with
initializeBrowserOrServerApp in its shared app.config.ts (#3845), so
the sentence about its split client and server files no longer holds.

Refs #3843

* docs: say how to move from 22.0.0-rc.0 in the v22 upgrade guide

The guide covered upgrades from 21, a 21 release candidate and a 22
canary build, but not from the first 22 release candidate. ng add saves
a prerelease as an exact version, so an app on 22.0.0-rc.0 stays there
until ng update @angular/fire@next moves it. That command also runs
migration-v22 again, which reports "No changes made" for a firebase
entry already inside the supported range.

Refs #3834

* docs(auth): say which hooks stop a releaseAfterRender release

The caveat on releaseAfterRender said a throwing ngOnDestroy "stops that
teardown". It now says the release is a DestroyRef.onDestroy callback,
that Angular runs it after every service's and component's ngOnDestroy,
and that an ngOnDestroy method or an ApplicationRef.onDestroy listener
that throws keeps it from running. The default release, which does not
run during the injector's destroy, is unaffected either way.

A new paragraph says to call initializeBrowserOrServerApp in
app.config.ts rather than a route's providers: a route's child injector
is not destroyed with the request, so a release registered there never
runs.

Refs #3844

* docs(auth): say the default release still happens from a route's providers

Tyler asked on #3844 whether the default release, now tied to a root
service, releases a server app built in a lazy route's providers. It
does: the request's root injector is destroyed, the route's child
injector is not, so only a releaseAfterRender release registered there
is lost. Checked with a child environment injector that is never
destroyed, in both modes.

Refs #3844

* docs: add onConfigUpdate to the calls that do not hold the app unstable

#3829 set onConfigUpdate to blockUntilFirst: false in tools/build.ts,
because its observer runs only when a fetched template differs from
the active one. The list in docs/zones.md left it out. Found in the
#3835 review.

Refs #3829

* docs(auth): say a second provideFirebaseApp can render signed out

The bullet said a second registration makes a signed-in render fail
on a freshly started server. With more than one registration,
FirebaseApp resolves to the default app. Run on fresh servers, a
second plain initializeApp(config) registration creates that app on
the first request, so signed-in pages render signed out with no
error. Only when no registration creates the default app, as with
two initializeBrowserOrServerApp registrations, does the render
fail, and only until a signed-out request creates it. Found in the
#3835 review.

Refs #3844
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change Upgrading apps must change something. Call it out in the release notes bump: major Size of the change: breaking, ships only in a major release comp: compat Compatibility layer for the pre-modular API (src/compat). comp: ssr Server-side rendering, hydration, @angular/ssr interop. comp: zones Change detection / zone.js / zoneless. type: feature New capability or enhancement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants